Skip to content

Redis 原理 ​

Redis 已经返回 OK,机器突然掉电,数据还在不在?把十条命令放进 Pipeline,其他客户端能不能插进来?主节点故障后,副本接任会不会丢掉刚才成功的写入?这三个问题分别属于持久化、执行隔离和复制,不能用一句“Redis 是单线程的”一起回答。

本文沿着一条写请求往下看:谁读取和执行命令,数据如何离开内存,内存不足如何处理,多节点如何接手,以及应用怎样组合多条命令。实现边界固定为 Redis 8.2.2;单机命令行为在 Linux/amd64 上实测。持久化掉电、哨兵选举和 Cluster 分区没有在本次实验中演练,下面会把官方保证与本机观察分开。

一条命令经过哪些地方 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C["客户端发送命令"] --> I["连接读取与 RESP 解析<br/>主线程或 I/O 线程"]
    I --> M["主线程执行常规数据命令"]
    M --> D["修改内存数据"]
    D --> R["生成客户端回复"]
    D --> A["AOF 缓冲/文件<br/>若启用"]
    D --> P["复制流<br/>异步发送副本"]
    R --> O["网络写出<br/>主线程或 I/O 线程"]
    O --> C
    A --> F["按策略 fsync"]
    D -. "定期快照或重写" .-> B["子进程生成 RDB/AOF base"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C["客户端发送命令"] --> I["连接读取与 RESP 解析<br/>主线程或 I/O 线程"]
    I --> M["主线程执行常规数据命令"]
    M --> D["修改内存数据"]
    D --> R["生成客户端回复"]
    D --> A["AOF 缓冲/文件<br/>若启用"]
    D --> P["复制流<br/>异步发送副本"]
    R --> O["网络写出<br/>主线程或 I/O 线程"]
    O --> C
    A --> F["按策略 fsync"]
    D -. "定期快照或重写" .-> B["子进程生成 RDB/AOF base"]

图中的箭头表示数据去向,并不表示每条路径都在回复之前完成。比如默认异步复制下,回复 OK 时副本可能还没有执行这条写入。持久化是否在回复前完成,则取决于 AOF 是否启用、fsync 策略和故障模型。

“单线程”指向哪一层 ​

Redis 8.2.2 的常规内建数据命令主要在主线程执行。事件循环复用许多连接,一条连接没数据可读时,不必让整个线程等在那里。内存操作和紧凑的命令路径,使很多小命令不需要昂贵的线程切换与数据锁协调。事件循环、网络与命令处理

这不等于 Redis 进程只有一个线程。后台线程可承担部分刷盘、关闭文件、异步释放等工作;RDB 快照和 AOF 重写还会用子进程。8.2.2 启用 I/O 多线程后,读写网络和协议解析可以交给 I/O 线程,再把完整命令交回主线程。旧版本教程中关于 io-threads-do-reads 的配置方式,不应不看版本就直接搬过来。8.2.2 I/O 线程、配置说明

因此,加 I/O 线程主要针对网络和协议处理瓶颈。一个执行很久的 Lua 脚本、一次巨大范围扫描,仍可能占住常规命令执行路径。CPU 没跑满所有核,也不说明这个实例还有等比例余量。先区分执行耗时、排队、网络返回量和客户端解码,再判断应缩短命令、拆分数据还是扩实例。

BLPOP 一类“阻塞命令”是另一个容易混淆的词:它阻塞等待结果的客户端,服务端把客户端挂起后仍能处理其他连接;长时间执行的普通脚本则会影响其他命令得到执行的机会。二者不能因为都叫阻塞而混为一谈。

持久化:写进内存、写进内核、落到稳定存储并非一步 ​

RPO 是故障后可接受丢失多近的一段数据;RTO 是恢复服务允许花多久。配置持久化时,先决定这两个目标。把数据库查询结果缓存下来,与把唯一的订单事实存进去,对丢失的容忍度完全不同。

方案保存什么主要恢复方式丢失与成本边界
不持久化只保留内存从其他权威源重建进程消失即可全失;必须控制冷启动回源
RDB某时刻的数据快照加载快照快照之后的修改可能丢失;生成快照涉及 fork 与写盘
AOF可恢复数据状态的写入记录加载 base 与增量日志安全性依赖 fsync;日志与重写需要磁盘、CPU 和内存余量
RDB 格式的 AOF base + 增量 AOF快照式基底加后续变化加载 base,再重放增量减小恢复负担,不会消除最近增量尚未刷盘的窗口

RDB:把某个时刻留下来 ​

SAVE 同步生成快照,会阻塞常规服务;BGSAVE 通过 fork 子进程生成快照,父进程继续处理请求。操作系统用写时复制共享原有页,父进程后续修改某些页时才产生副本。因此子进程写盘不意味着零代价:fork 需要时间,写入越活跃,额外内存与磁盘压力越值得关注。RDB 源码

RDB 的恢复点是已成功完成并保留下来的快照,不能只看 save 60 1000 就声称“最多丢 60 秒”:条件可能没触发,快照可能失败,拷贝到异地的备份还可能更旧。需要监控最近成功时间、失败状态,并做恢复验证。

AOF:刷盘策略决定何时可以放心 ​

write 把数据交给操作系统,不等同于断电后一定还能恢复。fsync 用于要求存储同步,但最终仍依赖操作系统、设备和硬件对写入持久性的实现。Redis 的 AOF 策略如下:AOF 配置、AOF 实现

appendfsync何时请求同步适合考虑它的情形不应许诺什么
always写入批次同步后再返回相应回复愿意用写延迟换更强本机持久性不能覆盖磁盘损毁、错误副本晋升等所有故障
everysec后台通常每秒同步常见的性能与丢失窗口折中“通常约一秒窗口”不是任意磁盘故障/停顿下的严格 SLA
no不由 Redis 主动周期性 fsync,交给系统可承受更大丢失窗口、数据可重建不能把内核页缓存里的成功写当作断电安全

no-appendfsync-on-rewrite yes 还会在后台保存/重写期间改变刷盘行为。阅读配置时只找到 everysec 就下结论,会漏掉这类例外。这里的 RPO 应同时考虑进程崩溃、主机掉电、磁盘故障、复制切换,而不是只测一次 kill。官方持久化说明

Redis 7 起使用多文件 AOF:manifest 描述 base 与后续 incremental 文件。重写是把当前状态压成新的基底,并继续记录其间的增量;它不会把每一次历史操作永久留作审计记录。aof-use-rdb-preamble yes 可以让 base 使用 RDB 格式,这就是常说的混合形式。它与“同时开启独立 RDB 快照和 AOF”相关,但不是同一件事。备份恢复时必须把相互匹配的文件集合保存好,不能只随手复制某一个 .aof。

备份解决“可以找回旧状态”,复制解决“另一台机器有一份数据”。误删也会被复制,多个副本不能代替可恢复备份。

过期与淘汰:一个按时间,一个按内存压力 ​

TTL 回答“这个键还能被认为有效多久”;淘汰回答“空间不够时舍弃谁”。它们都可能让 GET 返回不存在,但触发原因不同。

Redis 一方面在访问键时检查是否过期,另一方面周期性主动检查部分过期数据。到期不要求在那一毫秒立即把所有相关内存物理释放,但正常读取不应继续把已过期键作为有效结果返回。到期删除、惰性释放、RSS 何时下降属于不同环节。expire.c

下面验证了一个常见事故来源:覆盖值会怎样影响 TTL?

python
r.call('SET', 'lab08:ttl', 'old', 'PX', 60000)
print('ttl-before-positive:', r.call('PTTL', 'lab08:ttl') > 0)
r.call('SET', 'lab08:ttl', 'new')
print('ttl-after-set:', r.call('PTTL', 'lab08:ttl'))
print('missing-ttl:', r.call('PTTL', 'lab08:missing'))
text
ttl-before-positive: True
ttl-after-set: -1
missing-ttl: -2

普通 SET 覆盖值会移除原来的到期时间。要保留,使用 KEEPTTL;要重新设置,跟上 EX/PX。PTTL=-1 是键在但没有过期时间,-2 是键不存在。把两者都当成缓存未命中,会掩盖永久驻留的键。SET、PTTL

maxmemory 控制 Redis 用于淘汰判断的内存上限,不是整个进程 RSS 的硬上限。复制/AOF 缓冲、客户端与分配器等开销需要额外考虑。到达上限后由策略选择候选:淘汰文档

策略组候选范围与选择方法适合的假设典型风险
noeviction不主动淘汰以腾空间数据不可随意丢,应用能处理写入失败需要新增内存的写可能失败,不能依赖“有 Redis 就总能写”
allkeys-lru / allkeys-lfu所有键,近似最近使用 / 访问频率整个实例都能当缓存丢弃热度判断有采样与近似,热点变化需要观察
volatile-lru / volatile-lfu只从设置 TTL 的键中选混放永久数据和可丢缓存没有可淘汰键时仍会遇到内存不足
allkeys-random / volatile-random对应范围内随机选访问近似均匀,不需要精细热度可能淘汰有价值的热点
volatile-ttl有 TTL 的键中优先较早到期者TTL 能大致代表剩余价值TTL 不一定代表重建成本或访问频率

LRU 关注最近访问,LFU 关注一段时间内的访问频率并有衰减。Redis 的实现不是为所有键维护精确全局排序,maxmemory-samples 等参数会影响近似效果和成本。尤其别把锁、幂等记录和可丢缓存混在一个随意淘汰的实例里,然后期待它们都保持业务正确性。

多节点:副本、哨兵与 Cluster 各解决哪一层 ​

部署解决的问题谁接收写入仍需面对的事情
主从复制复制一份数据,可提供副本读主节点默认异步,副本可能落后;单靠复制不会自动选主
Sentinel监测主节点、协调切换、帮助发现新主当前主节点不分片;切换期间请求与已确认写入仍有风险
Redis Cluster按哈希槽分片,并为分片提供故障转移对应槽的主节点跨槽操作受限、拓扑变化、分区与副本滞后

复制不是每次写都等待所有副本 ​

主节点向副本传播变化。短暂断连时,复制 ID、offset 和 backlog 允许进行部分重同步;缺失部分不在 backlog 内时,需要完整同步。backlog 太小、网络中断太久,都可能把轻量补差变成昂贵的全量复制。复制机制

默认异步复制意味着下面的时序成立:

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant C as 客户端
    participant M as 原主节点
    participant R as 副本/未来主节点
    C->>M: SET order:7 accepted
    M-->>C: OK
    Note over M,R: 该写入还没复制到 R
    M--xM: 故障
    Note over R: 被晋升,但没有 order:7 的新值
    C->>R: GET order:7
    R-->>C: 旧值或不存在
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant C as 客户端
    participant M as 原主节点
    participant R as 副本/未来主节点
    C->>M: SET order:7 accepted
    M-->>C: OK
    Note over M,R: 该写入还没复制到 R
    M--xM: 故障
    Note over R: 被晋升,但没有 order:7 的新值
    C->>R: GET order:7
    R-->>C: 旧值或不存在

WAIT 可以等待指定数量副本确认复制进度,降低某些丢失风险,但不会把 Redis 自动变成强一致共识系统。它与 AOF fsync 也不是同一保证;WAITAOF 关注 AOF 同步,仍需核对返回的本地与副本确认数及超时。WAIT、WAITAOF

Sentinel 负责故障判断与切换,不搬走单节点容量上限 ​

一个 Sentinel 认为主节点失联,是主观下线;达到配置的判定条件后可进入客观下线。真正执行故障转移还涉及 Sentinel 多数授权和选举,quorum 与“多数”不能混为一个数字。副本被选中晋升后,其他副本转向新主,客户端需要重新发现主节点并处理断开的连接。Sentinel 文档

Sentinel 适合一个数据集能装进单主、但需要自动切换的场景。它不把一个大数据集拆到多个主节点,也不能凭空消除异步复制窗口。

Cluster 用 16384 个哈希槽分片 ​

Cluster 按 CRC16(key) mod 16384 决定槽,槽再归属某个主节点。一对大括号中符合规则的非空 hash tag 可以决定参与哈希的部分,例如 order:{7}:head 与 order:{7}:items 可同槽。括号解析有准确规则,别靠肉眼猜;可以用 CLUSTER KEYSLOT 验证。Cluster 规范

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["order:{7}:head<br/>order:{7}:items"] --> S["相同 hash tag 7 → 相同槽"]
    B["其他键"] --> T["按键计算其他槽"]
    S --> M1["主节点 A:拥有一部分槽"]
    T --> M2["主节点 B:拥有另一部分槽"]
    M1 -. "异步复制" .-> R1["A 的副本"]
    M2 -. "异步复制" .-> R2["B 的副本"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["order:{7}:head<br/>order:{7}:items"] --> S["相同 hash tag 7 → 相同槽"]
    B["其他键"] --> T["按键计算其他槽"]
    S --> M1["主节点 A:拥有一部分槽"]
    T --> M2["主节点 B:拥有另一部分槽"]
    M1 -. "异步复制" .-> R1["A 的副本"]
    M2 -. "异步复制" .-> R2["B 的副本"]

客户端维护槽到节点的映射;MOVED 表示应更新路由,迁移中的 ASK 是一次性重定向,需要按协议执行 ASKING。支持 Cluster 的客户端会处理这些细节,普通单机客户端不能假设连任意节点都能完成任意请求。

多键事务和 Lua 涉及的键通常必须处于同一槽。把所有业务都写成一个 hash tag 虽然容易满足同槽,却把数据与流量集中到一个分片,失去分片收益。按确实需要共同原子操作的业务边界分组更合理。

Cluster 故障转移仍基于异步副本,可能丢掉已确认但尚未复制的写。分区时少数侧停止写入存在检测时间窗口;整体可用性还受槽覆盖和配置影响。它不是“节点越多就自动强一致”,也不能自动把一个热 key 拆散。

Pipeline、事务与 Lua:先问需要哪种组合 ​

方法少网络往返多条命令不被其他常规命令穿插能按前面读到的值继续计算失败自动撤销此前写入
Pipeline是不保证客户端通常要等结果再发下一批否
MULTI / EXEC可一起发送EXEC 中队列顺序执行队列内不能在客户端读结果再决策否
WATCH + 事务仍可能多轮成功提交的队列有事务执行边界先读、计算,提交前检查被监视键冲突时不执行队列;运行时错误无回滚
Lua EVAL一次请求完成一段逻辑脚本执行期间保证原子执行边界服务端脚本里可以读、判断、写否

Pipeline 减少等待,不把一批命令变成事务 ​

依次发 100 条命令,每条都等回复,可能重复付出 100 次网络往返。Pipeline 先把若干命令发出去,再读回各自回复,让网络与服务端处理重叠。它不要求服务端把整批视作不可分割的事务,错误也会按命令分别返回。Pipeline

批次过大时,客户端请求队列、服务端输出缓冲以及解码阶段都可能积压。批量大小应按总字节数、命令成本与延迟预算控制,不能只按“每批一万条”盲目固定。超时后无法确认某条写是否已经执行,也不能把整批非幂等写无条件重放。

事务的两个失败阶段,结果完全不同 ​

下面先把计数器设成文本,然后在事务里执行 INCR 和另一条合法的 SET:

python
r.call('SET', 'lab08:n', 'text')
r.call('MULTI')
r.call('INCR', 'lab08:n')
r.call('SET', 'lab08:ok', 'yes')
result = r.call('EXEC')

实测:

text
exec: ['ERR value is not an integer or out of range', 'OK']
after-exec: text yes

INCR 到执行时才知道值不是整数,于是该命令失败;后面的 SET 仍成功。Redis 不会撤销已成功的命令。代码必须检查每一个结果,不能只因 EXEC 已返回就认为所有业务动作完成。事务文档、multi.c

如果错误在排队时已经能发现,例如 SET 缺少值:

text
queue-error: ERR wrong number of arguments for 'set' command
abort: EXECABORT Transaction discarded because of previous errors.

这时整个队列不会执行。它和运行时错误保留部分效果的情况必须分开。

WATCH 用来做乐观并发控制。客户端先监视键并读取,另一个客户端在提交前修改了键,EXEC 返回空结果,队列不执行。本次两个连接实测:

text
watch-exec: None value= 2

第一个连接想把值写成 3,但另一个连接已写入 2,提交被放弃。应用可以重新读取再尝试,但要限制重试次数、总时间,并避免在事务重试函数中重复执行付款、发消息等外部副作用。

Lua 原子执行,不等于运行错误会回滚 ​

脚本适合把“读取、检查、修改”放在一次服务端调用中。例如库存不足便拒绝,否则扣减库存;锁释放前核对拥有者令牌。键通过 KEYS 明确传入,其他参数放在 ARGV,Cluster 中还要满足同槽约束。

lua
redis.call('SET', KEYS[1], 'written')
return redis.call('INCR', KEYS[2])

第二个键是前面的文本计数器,实测:

text
lua-error: ERR value is not an integer or out of range
after-lua: written

脚本失败了,但第一条 SET 留下了。应在写入前完成能做的参数、类型和业务检查,避免把脚本当成有 undo log 的数据库事务。Lua 运行模型、脚本命令执行

脚本执行期间,其他常规命令不能插入中间观察其逐条变化。代价是长脚本会拖住其他请求。不要在脚本里扫描无限集合或做没有上界的循环;达到超时阈值也不等于服务端可以安全地撤销已经发生的写。脚本缓存也不是永久注册表,EVALSHA 可能收到 NOSCRIPT,客户端应按库的脚本加载机制处理。

原子执行描述同一实例上的观察顺序;持久化描述崩溃后能否恢复;复制描述其他节点何时得到数据。Lua 同样不能替代后两者。

看监控时,把异常连回这些路径 ​

“Redis 慢”至少可能出在四个地方:客户端连接池排队,网络传输,命令本身执行,后台持久化或内存活动造成的资源竞争。SLOWLOG 主要记录命令执行部分,不包含完整客户端排队与网络往返,因此慢请求不在 SLOWLOG 中不代表 Redis 链路没问题。SLOWLOG

开始排查时,分别看应用端总耗时与连接池等待、Redis 命令耗时、输入输出字节、命中/过期/淘汰计数、主从延迟和持久化最近失败。把现象定位到路径之后,再决定是改查询、拆 key、限制批次还是调整部署。改线程数和增内存,应当有明确要解决的瓶颈。

依据与验证范围 ​

  • Redis 8.2.2 源码:正文分别引用 ae.c、networking.c、iothread.c、rdb.c、aof.c、expire.c、multi.c、script.c 与 redis.conf,用于固定实现边界。
  • 官方文档:正文中的持久化、复制、Sentinel、Cluster 规范、淘汰、事务、Lua。
  • 实测:Redis 8.2.2,Linux/amd64,libc,临时单节点、关闭持久化;验证排队错误、事务运行错误、WATCH 冲突、Lua 部分写入与 TTL 覆盖。Python 客户端只实现本实验需要的 RESP2 子集,按 UTF-8 解码,不能当作通用二进制安全或生产客户端。
  • 未实测:I/O 多线程吞吐、RDB/AOF 恢复与断电、复制断链、Sentinel 和 Cluster 故障转移。部署表是依据文档的机制说明,不是对本环境的容灾认证。

完整复现实验 ​

需要 Python 3、Bash、Redis 8.2.2。服务端和客户端二进制来自官方发布包,官方 SHA-256 为 4e340e8e822a82114b6fb0f7ca581b749fa876e31e36e9fbcb75416bec9d0608,可对照 redis-hashes。编译命令是 make -j4 MALLOC=libc BUILD_TLS=no OPTIMIZATION=-O2 redis-server redis-cli。本实验只创建教学数据,不能连到现有生产实例。

将以下三个文件放在同一目录,设置 REDIS_BIN 为二进制所在目录后执行。包装器创建临时目录,确认连接到的是自己启动的 PID,结束时关闭进程并删除临时数据。若端口已被占用,应另选空闲端口。

bash
export REDIS_BIN="$PWD/redis-8.2.2/src"
export REDIS_PORT=16389
"$REDIS_BIN/redis-server" --version
bash with-redis.sh python3 principles.py

with-redis.sh ​

bash
#!/usr/bin/env bash
set -euo pipefail
: "${REDIS_BIN:?Set REDIS_BIN to the directory containing redis-server and redis-cli}"
export REDIS_PORT=${REDIS_PORT:-16389}
work=$(mktemp -d)
log=${REDIS_RUN_LOG:-$work/server.log}
pid=
cleanup() {
  if [[ -n "$pid" ]]; then
    kill -TERM "$pid" 2>/dev/null || true
    wait "$pid" 2>/dev/null || true
  fi
  rm -rf -- "$work"
}
trap cleanup EXIT
"$REDIS_BIN/redis-server" --bind 127.0.0.1 --port "$REDIS_PORT" \
  --protected-mode yes --save '' --appendonly no --dir "$work" \
  --daemonize no > "$log" 2>&1 &
pid=$!
echo "redis_pid=$pid port=$REDIS_PORT temporary_dir=$work" >&2
ready=0
for ((i=0;i<100;i++)); do
  kill -0 "$pid" 2>/dev/null || { cat "$log" >&2; exit 1; }
  if "$REDIS_BIN/redis-cli" -h 127.0.0.1 -p "$REDIS_PORT" INFO server 2>/dev/null | tr -d '\r' | grep -qx "process_id:$pid"; then
    ready=1; break
  fi
  sleep 0.05
done
[[ "$ready" == 1 ]] || { cat "$log" >&2; exit 1; }
"$@"

resp.py ​

代码块 · 37 行 · 点击收起 / 展开
python
import os
import socket

class RedisError(Exception):
    pass

class Redis:
    def __init__(self):
        self.sock = socket.create_connection(('127.0.0.1', int(os.environ.get('REDIS_PORT', '16389'))), timeout=3)
        self.f = self.sock.makefile('rb')
    def close(self):
        self.f.close()
        self.sock.close()
    def read(self):
        line = self.f.readline()
        if not line.endswith(b'\r\n'):
            raise EOFError('incomplete RESP line')
        kind, body = line[:1], line[1:-2]
        if kind == b'+': return body.decode()
        if kind == b'-': return RedisError(body.decode())
        if kind == b':': return int(body)
        if kind == b'$':
            n = int(body)
            if n == -1: return None
            data = self.f.read(n)
            if len(data) != n or self.f.read(2) != b'\r\n': raise EOFError('incomplete bulk')
            return data.decode()
        if kind == b'*':
            n = int(body)
            return None if n == -1 else [self.read() for _ in range(n)]
        raise ValueError('unsupported RESP type')
    def call(self, *args):
        vals = [str(a).encode() for a in args]
        wire = b'*%d\r\n' % len(vals)
        wire += b''.join(b'$%d\r\n' % len(v) + v + b'\r\n' for v in vals)
        self.sock.sendall(wire)
        return self.read()

principles.py ​

python
from resp import Redis, RedisError
r, other = Redis(), Redis()
try:
    r.call('SET','lab08:n','text')
    print('multi:',r.call('MULTI'))
    print('queue:',r.call('INCR','lab08:n'),r.call('SET','lab08:ok','yes'))
    result=r.call('EXEC')
    print('exec:',[str(x) if isinstance(x,RedisError) else x for x in result])
    print('after-exec:',r.call('GET','lab08:n'),r.call('GET','lab08:ok'))
    r.call('MULTI')
    print('queue-error:',str(r.call('SET','lab08:bad')))
    print('abort:',str(r.call('EXEC')))
    r.call('SET','lab08:v','1')
    r.call('WATCH','lab08:v')
    other.call('SET','lab08:v','2')
    r.call('MULTI')
    r.call('SET','lab08:v','3')
    print('watch-exec:',r.call('EXEC'),'value=',r.call('GET','lab08:v'))
    script="redis.call('SET', KEYS[1], 'written'); return redis.call('INCR', KEYS[2])"
    print('lua-error:',str(r.call('EVAL',script,2,'lab08:lua','lab08:n')).split(' script:')[0])
    print('after-lua:',r.call('GET','lab08:lua'))
    r.call('SET','lab08:ttl','old','PX',60000)
    print('ttl-before-positive:',r.call('PTTL','lab08:ttl')>0)
    r.call('SET','lab08:ttl','new')
    print('ttl-after-set:',r.call('PTTL','lab08:ttl'))
    print('missing-ttl:',r.call('PTTL','lab08:missing'))
finally:
    r.close(); other.close()

保留各书来源与许可 · 示例运行条件以正文为准